人跟人之間需要溝通,硬體與硬體之間當然也需要溝通。然而在硬體與軟體的通訊協定,兩者要考慮的設計細節往往不太一樣,今天來介紹一個在電腦硬體當中相當常見的通訊協定 - UART。
UART 是一個非同步的通訊協定,通常是直接在硬體電路上實作,也可以直接寫程式實現。
這邊的非同步,跟軟體開發常見的非同步不太一樣。在軟體開發上,我們常用程式需不需要等待 I/O 完成才能向下執行來定義非同步,例如讀寫檔案系統、發送網路請求等等。而這邊指的非同步是發送端與接收端並不是由共同的時鐘信號來持續對齊,而是透過事先決定好頻率與開始、停止信號來判斷何時讀取資料。
假設我們要傳送 10010
給另外一個硬體設備好了,一個簡單的方式是一次接上 5 條線,然後同時傳送 1 0 0 1 0,如下圖所示:
雖然看起來很直覺,不過這樣一來,如果有 16bit 就要 16 個腳位,如果要 32bit 就要 32 個腳位。在線路上,如果可以我們當然會希望接腳少一點,就跟大家都愛無線是類似的道理,因為相對簡單、製程也會變得比較容易。
那麼有沒有辦法可以減少腳位的使用?假設雙方協議好每隔 100ms 會送一個 bit 過來,這樣 1600ms 過後就可以收到 16bit 了,如下圖所示:
回到上圖,如果只有一條線並且只有約定好每隔多久接收一次資料似乎還不夠,因為在這個協定下我們不知道資料何時送來,何時結束因此我們也需要一個信號,讓雙方可以知道何時開始與結束傳遞資料。
UART 中定義了 start bit 以及 stop bit 讓雙方知道何時會開始送資料、何時停止。
在閒置(IDLE)的狀態下會維持在高電位,而要開始傳送資料時則會轉為低電位當作 start bit,再開始傳送資料。每個資料幀(dataframe)為 8 ~ 9bit,最後一個 bit 為可選的奇偶校驗位。資料傳遞完之後拉到高電位表示 stop bit。
不像其他的通訊協定,UART 並沒有 clock 信號可供參考,所以雙方需要事先知道彼此的 baud rate,才知道雙方是以多快的速度傳送資料。為什麼這個數字那麼重要呢?可以參考下圖:
紅色部分為正確的 baud rate,可以看到資料被正確讀取,但如果將 baud rate 提升到兩倍,則會出現同一個 bit 但卻重複讀取兩次的情形(咖啡色部分)。
要使用 UART 必須符合下列幾個條件:
在 Arduino 中通常都會內建 UART 的功能。更精準地來說是如果 Arduino 板子是使用有支援 UART 的 IC 的話,就會有 UART 可以使用。 通常會在 0, 1 這兩個接腳,標示為 tx 與 rx。
tx 表示 transmit,傳送資料給另一個設備;rx 表示 receiver,從另一設備的 tx 讀取資料。RX 接腳會接到另一設備的 TX 接腳;而 TX 接腳會接到另一設備的 RX 接腳。
以 AVR 為例,有數個暫存器是專門給 UART 通訊使用的。詳細的使用方式可以參考 Datasheet,這邊大致上要說明 UART 是透過哪些暫存器在控制的:
UBRR
:控制 baud rate 的暫存器。UART 需要事先規範雙方的 baud rate 為何,baud rate 的資訊通常會寫在硬體的 datasheet 上面USCRB
:控制是否接收 Rx
或 Tx
的資料UCSRC
:控制通信時的參數,如 data bit、是否要傳送檢查碼等等UDR
:這個暫存器會存放接收到或要傳出的資料。根據其他的暫存器設定決定UCSRnA
UCSRnB
UCSRnC
:這個暫存器保存了 UART 的各種狀態,像是是否已經傳輸、接收完畢、是否可以接收新的資料、是否要啟用中斷(當資料傳輸、接收時發出)等等由於收發都是使用 UDR
暫存器,因此我的理解是同一時間不會發生同時接收和傳送。但是這樣不會發生 資料遺失的問題嗎?例如當有 100bytes 正在傳送過來,同時有 1byte 要傳送出去。那麼我的理解是:
因此就算 Tx / Rx 兩條線是分開的,同一時間也只能傳送或接收。這邊我還不確定,先把問題丟上來給大家一起思考。
Arduino 將 UART 包裝成 Serial 介面,這樣一來我們就不用管背後的暫存器設定邏輯,也不用大費周章 1byte 1byte 傳送,Serial 都會幫我們處理好。常見的 API 如下:
void setup() {
Serial.begin(9600); // 在這邊設定 baud rate
}
void loop() {
if (Serial.available() > 0) {
incomingByte = Serial.read();
Serial.print("I received: ");
Serial.println(incomingByte, DEC);
}
}
由於 UART 的實作是在硬體上數量有限,然而協定本身很簡單,基本上可以理解成在對的時間點送出對的資料。因此只要時間對得上,也可以自己寫程式達成。
說來簡單,但要寫程式自己控制 timing 其實是一件很麻煩的事,尤其是在高頻率的通訊當中,幾毫秒的誤差就是完全不同的結果。
Arduino 內建的 Library — SoftwareSerial 實作了 UART 協定,任何 GPIO 接腳都可以拿來用。看到 Software 可以得知,這是利用程式來模擬 UART 的通訊,有點像是影片中常見的硬體編碼與軟體編碼的感覺。
SoftwareSerial 背後的實作相當精彩,為了讓時間更加精準,針對不同的 gcc 版本給了不同的 offset 來調整 cycle 數。
在 Software::begin
中可以看到實作
void SoftwareSerial::begin(long speed)
{
// 略
// Precalculate the various delays, in number of 4-cycle delays
uint16_t bit_delay = (F_CPU / speed) / 4;
// 12 (gcc 4.8.2) or 13 (gcc 4.3.2) cycles from start bit to first bit,
// 15 (gcc 4.8.2) or 16 (gcc 4.3.2) cycles between bits,
// 12 (gcc 4.8.2) or 14 (gcc 4.3.2) cycles from last bit to stop bit
// These are all close enough to just use 15 cycles, since the inter-bit
// timings are the most critical (deviations stack 8 times)
_tx_delay = subtract_cap(bit_delay, 15 / 4);
// Only setup rx when we have a valid PCINT for this pin
if (digitalPinToPCICR((int8_t)_receivePin)) {
#if GCC_VERSION > 40800
// Timings counted from gcc 4.8.2 output. This works up to 115200 on
// 16Mhz and 57600 on 8Mhz.
//
// When the start bit occurs, there are 3 or 4 cycles before the
// interrupt flag is set, 4 cycles before the PC is set to the right
// interrupt vector address and the old PC is pushed on the stack,
// and then 75 cycles of instructions (including the RJMP in the
// ISR vector table) until the first delay. After the delay, there
// are 17 more cycles until the pin value is read (excluding the
// delay in the loop).
// We want to have a total delay of 1.5 bit time. Inside the loop,
// we already wait for 1 bit time - 23 cycles, so here we wait for
// 0.5 bit time - (71 + 18 - 22) cycles.
_rx_delay_centering = subtract_cap(bit_delay / 2, (4 + 4 + 75 + 17 - 23) / 4);
// There are 23 cycles in each loop iteration (excluding the delay)
_rx_delay_intrabit = subtract_cap(bit_delay, 23 / 4);
// There are 37 cycles from the last bit read to the start of
// stopbit delay and 11 cycles from the delay until the interrupt
// mask is enabled again (which _must_ happen during the stopbit).
// This delay aims at 3/4 of a bit time, meaning the end of the
// delay will be at 1/4th of the stopbit. This allows some extra
// time for ISR cleanup, which makes 115200 baud at 16Mhz work more
// reliably
_rx_delay_stopbit = subtract_cap(bit_delay * 3 / 4, (37 + 11) / 4);
#else // Timings counted from gcc 4.3.2 output
// Note that this code is a _lot_ slower, mostly due to bad register
// allocation choices of gcc. This works up to 57600 on 16Mhz and
// 38400 on 8Mhz.
_rx_delay_centering = subtract_cap(bit_delay / 2, (4 + 4 + 97 + 29 - 11) / 4);
_rx_delay_intrabit = subtract_cap(bit_delay, 11 / 4);
_rx_delay_stopbit = subtract_cap(bit_delay * 3 / 4, (44 + 17) / 4);
#endif
// Enable the PCINT for the entire port here, but never disable it
// (others might also need it, so we disable the interrupt by using
// the per-pin PCMSK register).
*digitalPinToPCICR((int8_t)_receivePin) |= _BV(digitalPinToPCICRbit(_receivePin));
// Precalculate the pcint mask register and value, so setRxIntMask
// can be used inside the ISR without costing too much time.
_pcint_maskreg = digitalPinToPCMSK(_receivePin);
_pcint_maskvalue = _BV(digitalPinToPCMSKbit(_receivePin));
tunedDelay(_tx_delay); // if we were low this establishes the end
}
...
}
雖然程式碼不多,不過註釋當中寫得很清楚,他們計算了每個 gcc 版本會需要花上的 CPU cycle 數,扣掉之後才做 delay。
這邊的 tunedDelay
的實作也很有趣,直接用組合語言寫!
我對 AVR 的組語指令集不熟,但看起來應該就是一個比較精準的 delay
函數。使用 volatile
是告訴 compiler 不需要幫這段程式碼做優化,因為這個變數有可能會有變化,每次讀取時都直接從位址存取。
void tunedDelay(uint16_t __count)
{
asm volatile (
"1: sbiw %0,1" "\n\t"
"brne 1b"
: "=w" (__count)
: "0" (__count)
);
}
這段組合語言的運作如下:
sbiw
指得是 immediate substract from word,%0
是一個 placeholder,會被第一個參數也就是 __count
取代。這段就是 __count
每次會減 1brne
: branch if not equal,如果 sbiw
的結果不為 0 則會跳回 1:
這個 label。"=w" (__count)
,第一個 output operand %0
為 __count
"0" (__count)
,第一個 input operand 為 __count
因此這段程式碼在做的事情是根據傳入的 count
值,進入一個無窮迴圈直到 count
為零,而且是根據 cycle 數計算而不是秒數。
然後在 SoftwareSerial::write
當中
size_t SoftwareSerial::write(uint8_t b)
{
volatile uint8_t *reg = _transmitPortRegister;
uint8_t reg_mask = _transmitBitMask;
uint8_t inv_mask = ~_transmitBitMask;
uint8_t oldSREG = SREG;
bool inv = _inverse_logic;
uint16_t delay = _tx_delay;
if (inv)
b = ~b;
cli(); // turn off interrupts for a clean txmit
// Write the start bit
if (inv)
*reg |= reg_mask;
else
*reg &= inv_mask;
tunedDelay(delay);
// Write each of the 8 bits
for (uint8_t i = 8; i > 0; --i)
{
if (b & 1) // choose bit
*reg |= reg_mask; // send 1
else
*reg &= inv_mask; // send 0
tunedDelay(delay);
b >>= 1;
}
// restore pin to natural state
if (inv)
*reg &= inv_mask;
else
*reg |= reg_mask;
SREG = oldSREG; // turn interrupts back on
tunedDelay(_tx_delay);
return 1;
}
這裡有幾個值得一提的程式碼片段:
*reg|= reg_mask
程式直接用操作暫存器的方式改變高低電位,而非一般的 digitalWrite()
的寫法。我猜是為了減少 compile 後的組語造成不必要的 instruction cycle?cli()
:解除 Arduino 的中斷機制。要做一些原子操作或時間相關的操作時可以使用,不過這也代表在資料傳遞的過程中,其他程序會暫時無法運作?令我不解的是為什麼不用官方文件中的 noInterrupts()
而是 cli()
?SREG = oldSREG
:根據描述這樣可以啟動 Interrupt?為什麼不直接呼叫 interrupts()
就好?其他的實作就不一一解釋,不過從觀察原始碼當中我們發現了幾件事:
delay
精確度不夠透過軟體層的實作來符合通訊協定的需求很常見,尤其是在 MCU 缺少對應的 Peripheral,透過軟體層實作可以減少額外的電路設計成本。然而,在實作上可以發現為了符合時間需求,MCU 要花額外的時間「空等」不能做其他任務。
UART 是硬體通訊裡蠻常見的電路,這篇文章介紹了 UART 的運作原理以及在 AVR 當中怎麼實現以及 Arduino 是如何包裝的。這篇文章也介紹了在軟體層上 Arduino 是怎麼實作 SoftwareSerial。
以軟體開發的角度來說,這些設計與觀點往往是我以前壓根兒不會去想的東西,然而在硬體設計來說,這些事情反而變得理所當然。
我在去年也有發過一篇關於 Raspberry Pi pico 的 PIO 文章,我們可以將 PIO 想像成具有 I/O 功能和簡易指令的小小型 CPU,可以在不阻塞 MCU 的情況下在軟體層實作相關的通訊協定。
剛開始完全不知道為什麼要有這個看似很彆扭的功能,我就直接寫程式碼就好了,為什麼要寫一個閹割版的組合語言?然而知道 UART 的原理、軟體層的實作,就能明白有個 PIO 來客製化自己想要的硬體協定是多麽令人感激的功能。